summaryrefslogtreecommitdiff
path: root/app/[lng]/evcp
diff options
context:
space:
mode:
Diffstat (limited to 'app/[lng]/evcp')
-rw-r--r--app/[lng]/evcp/bqtbe/page.tsx72
-rw-r--r--app/[lng]/evcp/budgetary/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/evcp/budgetary/[id]/layout.tsx80
-rw-r--r--app/[lng]/evcp/budgetary/[id]/page.tsx57
-rw-r--r--app/[lng]/evcp/budgetary/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/evcp/budgetary/page.tsx86
-rw-r--r--app/[lng]/evcp/equip-class/page.tsx75
-rw-r--r--app/[lng]/evcp/faq/manage/actions.ts48
-rw-r--r--app/[lng]/evcp/faq/manage/page.tsx38
-rw-r--r--app/[lng]/evcp/faq/page.tsx62
-rw-r--r--app/[lng]/evcp/form-list/page.tsx75
-rw-r--r--app/[lng]/evcp/items/page.tsx74
-rw-r--r--app/[lng]/evcp/layout.tsx17
-rw-r--r--app/[lng]/evcp/page.tsx8
-rw-r--r--app/[lng]/evcp/po/page.tsx65
-rw-r--r--app/[lng]/evcp/pq-criteria/page.tsx71
-rw-r--r--app/[lng]/evcp/pq/[vendorId]/page.tsx38
-rw-r--r--app/[lng]/evcp/pq/page.tsx71
-rw-r--r--app/[lng]/evcp/rfq/[id]/cbe/page.tsx53
-rw-r--r--app/[lng]/evcp/rfq/[id]/layout.tsx80
-rw-r--r--app/[lng]/evcp/rfq/[id]/page.tsx55
-rw-r--r--app/[lng]/evcp/rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/evcp/rfq/page.tsx80
-rw-r--r--app/[lng]/evcp/settings/layout.tsx68
-rw-r--r--app/[lng]/evcp/settings/page.tsx18
-rw-r--r--app/[lng]/evcp/settings/preferences/page.tsx17
-rw-r--r--app/[lng]/evcp/system/admin-users/page.tsx60
-rw-r--r--app/[lng]/evcp/system/layout.tsx75
-rw-r--r--app/[lng]/evcp/system/page.tsx56
-rw-r--r--app/[lng]/evcp/system/permissions/page.tsx17
-rw-r--r--app/[lng]/evcp/system/roles/page.tsx68
-rw-r--r--app/[lng]/evcp/tag-numbering/page.tsx74
-rw-r--r--app/[lng]/evcp/tasks/page.tsx63
-rw-r--r--app/[lng]/evcp/vendors/[id]/info/items/page.tsx56
-rw-r--r--app/[lng]/evcp/vendors/[id]/info/layout.tsx79
-rw-r--r--app/[lng]/evcp/vendors/[id]/info/page.tsx56
-rw-r--r--app/[lng]/evcp/vendors/[id]/info/rfq-history/page.tsx55
-rw-r--r--app/[lng]/evcp/vendors/page.tsx78
38 files changed, 2211 insertions, 0 deletions
diff --git a/app/[lng]/evcp/bqtbe/page.tsx b/app/[lng]/evcp/bqtbe/page.tsx
new file mode 100644
index 00000000..655bd30a
--- /dev/null
+++ b/app/[lng]/evcp/bqtbe/page.tsx
@@ -0,0 +1,72 @@
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getAllTBE } from "@/lib/rfqs/service"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+import { AllTbeTable } from "@/lib/tbe/table/tbe-table"
+import { RfqType } from "@/lib/rfqs/validations"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ }
+ searchParams: Promise<SearchParams>
+ rfqType: RfqType
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+
+ const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getAllTBE({
+ ...search,
+ filters: validFilters,
+ rfqType
+ }
+ )
+ ])
+
+ // 4) 렌더링
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Technical Bid Evaluation
+ </h2>
+ <p className="text-muted-foreground">
+ 초대된 벤더에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <AllTbeTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/budgetary/[id]/cbe/page.tsx b/app/[lng]/evcp/budgetary/[id]/cbe/page.tsx
new file mode 100644
index 00000000..9a4ae7eb
--- /dev/null
+++ b/app/[lng]/evcp/budgetary/[id]/cbe/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getCBE, getTBE } from "@/lib/rfqs/service"
+import { searchParamsCBECache, } from "@/lib/rfqs/validations"
+import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
+import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsCBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getCBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Commercial Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 벤더에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <CbeTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/budgetary/[id]/layout.tsx b/app/[lng]/evcp/budgetary/[id]/layout.tsx
new file mode 100644
index 00000000..39f045e5
--- /dev/null
+++ b/app/[lng]/evcp/budgetary/[id]/layout.tsx
@@ -0,0 +1,80 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정
+import { Rfq, RfqWithItems } from "@/db/schema/rfq"
+import { findRfqById } from "@/lib/rfqs/service"
+import { formatDate } from "@/lib/utils"
+
+export const metadata: Metadata = {
+ title: "Vendor Detail",
+}
+
+export default async function RfqLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string, id: string }
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 벤더 정보 조회
+ const rfq: RfqWithItems | null = await findRfqById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "Matched Vendors",
+ href: `/${lng}/evcp/budgetary/${id}`,
+ },
+ {
+ title: "TBE",
+ href: `/${lng}/evcp/budgetary/${id}/tbe`,
+ },
+ {
+ title: "CBE",
+ href: `/${lng}/evcp/budgetary/${id}/cbe`,
+ },
+
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {rfq
+ ? `${rfq.rfqCode ?? ""} 관리`
+ : "Loading RFQ..."}
+ </h2>
+
+ <p className="text-muted-foreground">
+ {rfq
+ ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
+ : ""}
+ </p>
+ <h3>Due Date:{ rfq && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/6">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/budgetary/[id]/page.tsx b/app/[lng]/evcp/budgetary/[id]/page.tsx
new file mode 100644
index 00000000..f6160574
--- /dev/null
+++ b/app/[lng]/evcp/budgetary/[id]/page.tsx
@@ -0,0 +1,57 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getMatchedVendors } from "@/lib/rfqs/service"
+import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
+import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
+import { RfqType } from "@/lib/rfqs/validations"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+ rfqType: RfqType
+}
+
+export default async function RfqPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+ const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ const searchParams = await props.searchParams
+ const search = searchParamsMatchedVCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getMatchedVendors({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Vendors
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 등록된 벤더 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/budgetary/[id]/tbe/page.tsx b/app/[lng]/evcp/budgetary/[id]/tbe/page.tsx
new file mode 100644
index 00000000..a6259696
--- /dev/null
+++ b/app/[lng]/evcp/budgetary/[id]/tbe/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getTBE } from "@/lib/rfqs/service"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Technical Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 벤더에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <TbeTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/budgetary/page.tsx b/app/[lng]/evcp/budgetary/page.tsx
new file mode 100644
index 00000000..04550353
--- /dev/null
+++ b/app/[lng]/evcp/budgetary/page.tsx
@@ -0,0 +1,86 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { searchParamsCache } from "@/lib/rfqs/validations"
+import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
+import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
+import { getAllItems } from "@/lib/items/service"
+import { RfqType } from "@/lib/rfqs/validations"
+import { Ellipsis } from "lucide-react"
+
+interface RfqPageProps {
+ searchParams: Promise<SearchParams>;
+ rfqType: RfqType;
+ title: string;
+ description: string;
+}
+
+export default async function RfqPage({
+ searchParams,
+ rfqType = RfqType.BUDGETARY,
+ title = "Budgetary Quote",
+ description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다."
+}: RfqPageProps) {
+ const search = searchParamsCache.parse(await searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRfqs({
+ ...search,
+ filters: validFilters,
+ rfqType // 전달받은 rfqType 사용
+ }),
+ getRfqStatusCounts(rfqType), // rfqType 전달
+ getAllItems()
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {title}
+ </h2>
+ <p className="text-muted-foreground">
+ {description}
+ 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후,
+ <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqsTable promises={promises} rfqType={rfqType} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/equip-class/page.tsx b/app/[lng]/evcp/equip-class/page.tsx
new file mode 100644
index 00000000..fcda1c1d
--- /dev/null
+++ b/app/[lng]/evcp/equip-class/page.tsx
@@ -0,0 +1,75 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/equip-class/validation"
+import { FormListsTable } from "@/lib/form-list/table/formLists-table"
+import { getTagClassists } from "@/lib/equip-class/service"
+import { EquipClassTable } from "@/lib/equip-class/table/equipClass-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTagClassists({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Object Class List from S-EDP
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더 데이터 입력을 위한 Form 리스트입니다.{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <EquipClassTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/faq/manage/actions.ts b/app/[lng]/evcp/faq/manage/actions.ts
new file mode 100644
index 00000000..bc443a8a
--- /dev/null
+++ b/app/[lng]/evcp/faq/manage/actions.ts
@@ -0,0 +1,48 @@
+'use server';
+
+import { promises as fs } from 'fs';
+import path from 'path';
+import { FaqCategory } from '@/components/faq/FaqCard';
+import { fallbackLng } from '@/i18n/settings';
+
+const FAQ_CONFIG_PATH = path.join(process.cwd(), 'config', 'faqDataConfig.ts');
+
+export async function updateFaqData(lng: string, newData: FaqCategory[]) {
+ try {
+ const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
+ const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
+ if (!dataMatch) {
+ throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
+ }
+
+ const allData = eval(`(${dataMatch[1]})`);
+ const updatedData = {
+ ...allData,
+ [lng]: newData
+ };
+
+ const newFileContent = `import { FaqCategory } from "@/components/faq/FaqCard";\n\ninterface LocalizedFaqCategories {\n [lng: string]: FaqCategory[];\n}\n\nexport const faqCategories: LocalizedFaqCategories = ${JSON.stringify(updatedData, null, 4)};`;
+ await fs.writeFile(FAQ_CONFIG_PATH, newFileContent, 'utf-8');
+
+ return { success: true };
+ } catch (error) {
+ console.error('FAQ 데이터 업데이트 중 오류 발생:', error);
+ return { success: false, error: '데이터 업데이트 중 오류가 발생했습니다.' };
+ }
+}
+
+export async function getFaqData(lng: string): Promise<{ data: FaqCategory[] }> {
+ try {
+ const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
+ const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
+ if (!dataMatch) {
+ throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
+ }
+
+ const allData = eval(`(${dataMatch[1]})`);
+ return { data: allData[lng] || allData[fallbackLng] || [] };
+ } catch (error) {
+ console.error('FAQ 데이터 읽기 중 오류 발생:', error);
+ return { data: [] };
+ }
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/faq/manage/page.tsx b/app/[lng]/evcp/faq/manage/page.tsx
new file mode 100644
index 00000000..011bbfa4
--- /dev/null
+++ b/app/[lng]/evcp/faq/manage/page.tsx
@@ -0,0 +1,38 @@
+import { FaqManager } from '@/components/faq/FaqManager';
+import { getFaqData, updateFaqData } from './actions';
+import { revalidatePath } from 'next/cache';
+import { FaqCategory } from '@/components/faq/FaqCard';
+
+interface Props {
+ params: {
+ lng: string;
+ }
+}
+
+export default async function FaqManagePage(props: Props) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const { data } = await getFaqData(lng);
+
+ async function handleSave(newData: FaqCategory[]) {
+ 'use server';
+ await updateFaqData(lng, newData);
+ revalidatePath(`/${lng}/evcp/faq`);
+ }
+
+ return (
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="space-y-6 p-10 pb-16">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">FAQ Management</h2>
+ <p className="text-muted-foreground">
+ Manage FAQ categories and items for {lng.toUpperCase()} language.
+ </p>
+ </div>
+ <FaqManager initialData={data} onSave={handleSave} lng={lng} />
+ </div>
+ </section>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/faq/page.tsx b/app/[lng]/evcp/faq/page.tsx
new file mode 100644
index 00000000..9b62b7e4
--- /dev/null
+++ b/app/[lng]/evcp/faq/page.tsx
@@ -0,0 +1,62 @@
+import { Separator } from "@/components/ui/separator"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { faqCategories } from "@/config/faqDataConfig"
+import { FaqCard } from "@/components/faq/FaqCard"
+import { Button } from "@/components/ui/button"
+import { Settings } from "lucide-react"
+import Link from "next/link"
+import { fallbackLng } from "@/i18n/settings"
+
+interface Props {
+ params: {
+ lng: string;
+ }
+}
+
+export default async function FaqPage(props: Props) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const localizedFaqCategories = faqCategories[lng] || faqCategories[fallbackLng];
+
+ return (
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="space-y-6 p-10 pb-16">
+ <div className="flex justify-between items-center">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">Frequently Asked Questions</h2>
+ <p className="text-muted-foreground">
+ Find answers to common questions about using the EVCP system.
+ </p>
+ </div>
+ <Link href={`/${lng}/evcp/faq/manage`}>
+ <Button variant="outline">
+ <Settings className="w-4 h-4 mr-2" />
+ Manage FAQ
+ </Button>
+ </Link>
+ </div>
+ <Separator className="my-6" />
+
+ <Tabs defaultValue={localizedFaqCategories[0]?.label} className="space-y-4">
+ <TabsList>
+ {localizedFaqCategories.map((category) => (
+ <TabsTrigger key={category.label} value={category.label}>
+ {category.label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+
+ {localizedFaqCategories.map((category) => (
+ <TabsContent key={category.label} value={category.label} className="space-y-4">
+ {category.items.map((item, index) => (
+ <FaqCard key={index} item={item} />
+ ))}
+ </TabsContent>
+ ))}
+ </Tabs>
+ </div>
+ </section>
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/form-list/page.tsx b/app/[lng]/evcp/form-list/page.tsx
new file mode 100644
index 00000000..f96917d6
--- /dev/null
+++ b/app/[lng]/evcp/form-list/page.tsx
@@ -0,0 +1,75 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/form-list/validation"
+import { ItemsTable } from "@/lib/items/table/items-table"
+import { getFormLists } from "@/lib/form-list/service"
+import { FormListsTable } from "@/lib/form-list/table/formLists-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getFormLists({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Form List from S-EDP
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더 데이터 입력을 위한 Form 리스트입니다.{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <FormListsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/items/page.tsx b/app/[lng]/evcp/items/page.tsx
new file mode 100644
index 00000000..144689ff
--- /dev/null
+++ b/app/[lng]/evcp/items/page.tsx
@@ -0,0 +1,74 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/items/validations"
+import { getItems } from "@/lib/items/service"
+import { ItemsTable } from "@/lib/items/table/items-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getItems({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Package Items
+ </h2>
+ <p className="text-muted-foreground">
+ Item을 등록하고 관리할 수 있습니다.{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ItemsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/layout.tsx b/app/[lng]/evcp/layout.tsx
new file mode 100644
index 00000000..9dc39f7b
--- /dev/null
+++ b/app/[lng]/evcp/layout.tsx
@@ -0,0 +1,17 @@
+import { ReactNode } from 'react';
+import { Header } from '@/components/layout/Header';
+import { SiteFooter } from '@/components/layout/Footer';
+
+export default function EvcpLayout({ children }: { children: ReactNode }) {
+ return (
+ <div className="relative flex min-h-svh flex-col bg-background">
+ <Header />
+ <main className="flex flex-1 flex-col">
+ <div className='container-wrapper'>
+ {children}
+ </div>
+ </main>
+ <SiteFooter/>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/page.tsx b/app/[lng]/evcp/page.tsx
new file mode 100644
index 00000000..a1e9f8be
--- /dev/null
+++ b/app/[lng]/evcp/page.tsx
@@ -0,0 +1,8 @@
+
+export default function Pages() {
+ return (
+ <>
+ test
+ </>
+ )
+ } \ No newline at end of file
diff --git a/app/[lng]/evcp/po/page.tsx b/app/[lng]/evcp/po/page.tsx
new file mode 100644
index 00000000..fa528df0
--- /dev/null
+++ b/app/[lng]/evcp/po/page.tsx
@@ -0,0 +1,65 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getPOs } from "@/lib/po/service"
+import { searchParamsCache } from "@/lib/po/validations"
+import { PoListsTable } from "@/lib/po/table/po-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getPOs({
+ ...search,
+ filters: validFilters,
+ }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ PO 확인 및 전자서명
+ </h2>
+ <p className="text-muted-foreground">
+ 기간계 시스템으로부터 PO를 확인하고 벤더에게 전자서명을 요청할 수 있습니다. 요쳥된 전자서명의 이력 또한 확인할 수 있습니다.
+
+ </p>
+ </div>
+ </div>
+ </div>
+
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <PoListsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/pq-criteria/page.tsx b/app/[lng]/evcp/pq-criteria/page.tsx
new file mode 100644
index 00000000..d924890d
--- /dev/null
+++ b/app/[lng]/evcp/pq-criteria/page.tsx
@@ -0,0 +1,71 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { searchParamsCache } from "@/lib/pq/validations"
+import { getPQs } from "@/lib/pq/service"
+import { PqsTable } from "@/lib/pq/table/pq-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getPQs({
+ ...search,
+ filters: validFilters,
+ }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Pre-Qualification Check Sheet
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더 등록을 위한, 벤더가 제출할 PQ 항목을 관리할 수 있습니다.
+
+ </p>
+ </div>
+ </div>
+ </div>
+
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <PqsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/pq/[vendorId]/page.tsx b/app/[lng]/evcp/pq/[vendorId]/page.tsx
new file mode 100644
index 00000000..cb4277f1
--- /dev/null
+++ b/app/[lng]/evcp/pq/[vendorId]/page.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { Skeleton } from "@/components/ui/skeleton"
+
+import { type SearchParams } from "@/types/table"
+import { getPQDataByVendorId } from "@/lib/pq/service"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Vendor } from "@/db/schema/vendors"
+import { findVendorById } from "@/lib/vendors/service"
+import VendorPQReviewPage from "@/components/pq/pq-review-detail"
+import VendorPQAdminReview from "@/components/pq/pq-review-detail"
+
+interface IndexPageProps {
+ params: {
+ vendorId: string // Updated from 'id' to 'contractId' to match route parameter
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function DocumentListPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const vendorId = resolvedParams.vendorId // Updated from 'id' to 'contractId'
+
+ const idAsNumber = Number(vendorId)
+
+ const data = await getPQDataByVendorId(idAsNumber)
+
+ const vendor: Vendor | null = await findVendorById(idAsNumber)
+
+ // 4) 렌더링
+ return (
+ <Shell className="gap-2">
+ {vendor &&
+ <VendorPQAdminReview data={data} vendor={vendor} />
+ }
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/pq/page.tsx b/app/[lng]/evcp/pq/page.tsx
new file mode 100644
index 00000000..46b22b12
--- /dev/null
+++ b/app/[lng]/evcp/pq/page.tsx
@@ -0,0 +1,71 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { getVendorsInPQ } from "@/lib/pq/service"
+import { searchParamsCache } from "@/lib/vendors/validations"
+import { VendorsPQReviewTable } from "@/lib/pq/pq-review-table/vendors-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getVendorsInPQ({
+ ...search,
+ filters: validFilters,
+ }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Pre-Qualification Review
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더가 제출한 PQ를 확인하고 수정 요청 등을 할 수 있으며 PQ 종료 후에는 통과 여부를 결정할 수 있습니다.
+
+ </p>
+ </div>
+ </div>
+ </div>
+
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <VendorsPQReviewTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/rfq/[id]/cbe/page.tsx b/app/[lng]/evcp/rfq/[id]/cbe/page.tsx
new file mode 100644
index 00000000..bc32641f
--- /dev/null
+++ b/app/[lng]/evcp/rfq/[id]/cbe/page.tsx
@@ -0,0 +1,53 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqCBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ // const promises = Promise.all([
+ // getCBE({
+ // ...search,
+ // filters: validFilters,
+ // },
+ // idAsNumber)
+ // ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Technical Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 벤더에게 CBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/rfq/[id]/layout.tsx b/app/[lng]/evcp/rfq/[id]/layout.tsx
new file mode 100644
index 00000000..2aac90eb
--- /dev/null
+++ b/app/[lng]/evcp/rfq/[id]/layout.tsx
@@ -0,0 +1,80 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정
+import { Rfq, RfqWithItems } from "@/db/schema/rfq"
+import { findRfqById } from "@/lib/rfqs/service"
+import { formatDate } from "@/lib/utils"
+
+export const metadata: Metadata = {
+ title: "Vendor Detail",
+}
+
+export default async function RfqLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string, id: string }
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 벤더 정보 조회
+ const rfq: RfqWithItems | null = await findRfqById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "Matched Vendors",
+ href: `/${lng}/evcp/rfq/${id}`,
+ },
+ {
+ title: "TBE",
+ href: `/${lng}/evcp/rfq/${id}/tbe`,
+ },
+ {
+ title: "CBE",
+ href: `/${lng}/evcp/rfq/${id}/cbe`,
+ },
+
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {rfq
+ ? `${rfq.rfqCode ?? ""} 관리`
+ : "Loading RFQ..."}
+ </h2>
+
+ <p className="text-muted-foreground">
+ {rfq
+ ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
+ : ""}
+ </p>
+ <h3>Due Date:{ rfq && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/6">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/rfq/[id]/page.tsx b/app/[lng]/evcp/rfq/[id]/page.tsx
new file mode 100644
index 00000000..026ca5ac
--- /dev/null
+++ b/app/[lng]/evcp/rfq/[id]/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getMatchedVendors } from "@/lib/rfqs/service"
+import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
+import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsMatchedVCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getMatchedVendors({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Vendors
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 등록된 벤더 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <MatchedVendorsTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/rfq/[id]/tbe/page.tsx b/app/[lng]/evcp/rfq/[id]/tbe/page.tsx
new file mode 100644
index 00000000..15c5d93c
--- /dev/null
+++ b/app/[lng]/evcp/rfq/[id]/tbe/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getTBE } from "@/lib/rfqs/service"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Technical Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 벤더에게 TBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <TbeTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/rfq/page.tsx b/app/[lng]/evcp/rfq/page.tsx
new file mode 100644
index 00000000..3417b0bf
--- /dev/null
+++ b/app/[lng]/evcp/rfq/page.tsx
@@ -0,0 +1,80 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { searchParamsCache } from "@/lib/rfqs/validations"
+import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
+import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
+import { getAllItems } from "@/lib/items/service"
+import { RfqType } from "@/lib/rfqs/validations"
+
+interface RfqPageProps {
+ searchParams: Promise<SearchParams>;
+ rfqType: RfqType;
+ title: string;
+ description: string;
+}
+
+export default async function RfqPage({
+ searchParams,
+ rfqType = RfqType.PURCHASE,
+ title = "RFQ",
+ description = "RFQ를 등록하고 관리할 수 있습니다."
+}: RfqPageProps) {
+ const search = searchParamsCache.parse(await searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRfqs({
+ ...search,
+ filters: validFilters,
+ rfqType // 전달받은 rfqType 사용
+ }),
+ getRfqStatusCounts(rfqType), // rfqType 전달
+ getAllItems()
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {title}
+ </h2>
+ <p className="text-muted-foreground">
+ {description}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqsTable promises={promises} rfqType={rfqType} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/settings/layout.tsx b/app/[lng]/evcp/settings/layout.tsx
new file mode 100644
index 00000000..6f373567
--- /dev/null
+++ b/app/[lng]/evcp/settings/layout.tsx
@@ -0,0 +1,68 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+
+export const metadata: Metadata = {
+ title: "Settings",
+ // description: "Advanced form example using react-hook-form and Zod.",
+}
+
+
+interface SettingsLayoutProps {
+ children: React.ReactNode
+ params: { lng: string }
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string }
+}) {
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+
+
+ const sidebarNavItems = [
+
+ {
+ title: "Account",
+ href: `/${lng}/evcp/settings`,
+ },
+ {
+ title: "Preferences",
+ href: `/${lng}/evcp/settings/preferences`,
+ }
+
+
+ ]
+
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">Settings</h2>
+ <p className="text-muted-foreground">
+ Manage your account settings and preferences.
+ </p>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 ">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+
+
+ </>
+ )
+}
diff --git a/app/[lng]/evcp/settings/page.tsx b/app/[lng]/evcp/settings/page.tsx
new file mode 100644
index 00000000..a6eaac90
--- /dev/null
+++ b/app/[lng]/evcp/settings/page.tsx
@@ -0,0 +1,18 @@
+import { Separator } from "@/components/ui/separator"
+import { AccountForm } from "@/components/settings/account-form"
+
+export default function SettingsAccountPage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Account</h3>
+ <p className="text-sm text-muted-foreground">
+ Update your account settings. Set your preferred language and
+ timezone.
+ </p>
+ </div>
+ <Separator />
+ <AccountForm />
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/settings/preferences/page.tsx b/app/[lng]/evcp/settings/preferences/page.tsx
new file mode 100644
index 00000000..e2a88021
--- /dev/null
+++ b/app/[lng]/evcp/settings/preferences/page.tsx
@@ -0,0 +1,17 @@
+import { Separator } from "@/components/ui/separator"
+import { AppearanceForm } from "@/components/settings/appearance-form"
+
+export default function SettingsAppearancePage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Preference</h3>
+ <p className="text-sm text-muted-foreground">
+ Customize the preference of the app.
+ </p>
+ </div>
+ <Separator />
+ <AppearanceForm />
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/system/admin-users/page.tsx b/app/[lng]/evcp/system/admin-users/page.tsx
new file mode 100644
index 00000000..11a9e9fb
--- /dev/null
+++ b/app/[lng]/evcp/system/admin-users/page.tsx
@@ -0,0 +1,60 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { DateRangePicker } from "@/components/date-range-picker"
+import { Separator } from "@/components/ui/separator"
+
+import { searchParamsCache } from "@/lib/admin-users/validations"
+import { getAllCompanies, getAllRoles, getUserCountGroupByCompany, getUserCountGroupByRole, getUsers } from "@/lib/admin-users/service"
+import { AdmUserTable } from "@/lib/admin-users/table/ausers-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function UserTable(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getUsers({
+ ...search,
+ filters: validFilters,
+ }),
+ getUserCountGroupByCompany(),
+ getUserCountGroupByRole(),
+ getAllCompanies(),
+ getAllRoles()
+ ])
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Vendor Admin User Management</h3>
+ <p className="text-sm text-muted-foreground">
+ 협력업체의 유저 전체를 조회하고 어드민 유저를 생성할 수 있는 페이지입니다. 이곳에서 초기 유저를 생성시킬 수 있습니다. <br />생성 후에는 생성된 사용자의 이메일로 생성 통보 이메일이 발송되며 사용자는 이메일과 OTP로 로그인이 가능합니다.
+ </p>
+ </div>
+ <Separator />
+ <AdmUserTable promises={promises} />
+ </div>
+ </React.Suspense>
+
+ )
+}
diff --git a/app/[lng]/evcp/system/layout.tsx b/app/[lng]/evcp/system/layout.tsx
new file mode 100644
index 00000000..4885a028
--- /dev/null
+++ b/app/[lng]/evcp/system/layout.tsx
@@ -0,0 +1,75 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+
+export const metadata: Metadata = {
+ title: "System Setting",
+ // description: "Advanced form example using react-hook-form and Zod.",
+}
+
+
+interface SettingsLayoutProps {
+ children: React.ReactNode
+ params: { lng: string }
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string }
+}) {
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+
+
+ const sidebarNavItems = [
+
+ {
+ title: "Users",
+ href: `/${lng}/evcp/system`,
+ },
+ {
+ title: "Roles",
+ href: `/${lng}/evcp/system/roles`,
+ },
+ {
+ title: "Permissions",
+ href: `/${lng}/evcp/system/permissions`,
+ },
+ {
+ title: "Vendor Users",
+ href: `/${lng}/evcp/system/admin-users`,
+ },
+
+ ]
+
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">시스템 설정</h2>
+ <p className="text-muted-foreground">
+ 사용자, 롤, 접근 권한을 관리하세요.
+ </p>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 ">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+
+
+ </>
+ )
+}
diff --git a/app/[lng]/evcp/system/page.tsx b/app/[lng]/evcp/system/page.tsx
new file mode 100644
index 00000000..2d180028
--- /dev/null
+++ b/app/[lng]/evcp/system/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import * as React from "react"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsCache } from "@/lib/admin-users/validations"
+import { getAllRoles, getUsersEVCP } from "@/lib/users/service"
+import { getUserCountGroupByRole } from "@/lib/admin-users/service"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { UserTable } from "@/lib/users/table/users-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SystemUserPage(props: IndexPageProps) {
+
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getUsersEVCP({
+ ...search,
+ filters: validFilters,
+ }),
+ getUserCountGroupByRole(),
+ getAllRoles()
+ ])
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "12rem", "12rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Users</h3>
+ <p className="text-sm text-muted-foreground">
+ 시스템 전체 사용자들을 조회하고 관리할 수 있는 페이지입니다. 사용자에게 롤을 할당하는 것으로 메뉴별 권한을 관리할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <UserTable promises={promises} />
+ </div>
+ </React.Suspense>
+
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/system/permissions/page.tsx b/app/[lng]/evcp/system/permissions/page.tsx
new file mode 100644
index 00000000..6aa2b693
--- /dev/null
+++ b/app/[lng]/evcp/system/permissions/page.tsx
@@ -0,0 +1,17 @@
+import PermissionsTree from "@/components/system/permissionsTree"
+import { Separator } from "@/components/ui/separator"
+
+export default function PermissionsPage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Permissions</h3>
+ <p className="text-sm text-muted-foreground">
+ Set permissions to the menu by Role
+ </p>
+ </div>
+ <Separator />
+ <PermissionsTree/>
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/system/roles/page.tsx b/app/[lng]/evcp/system/roles/page.tsx
new file mode 100644
index 00000000..fe074600
--- /dev/null
+++ b/app/[lng]/evcp/system/roles/page.tsx
@@ -0,0 +1,68 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Separator } from "@/components/ui/separator"
+
+import { searchParamsCache } from "@/lib/roles/validations"
+import { searchParamsCache as searchParamsCache2 } from "@/lib/admin-users/validations"
+import { RolesTable } from "@/lib/roles/table/roles-table"
+import { getRolesWithCount } from "@/lib/roles/services"
+import { getUsersAll } from "@/lib/users/service"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function UserTable(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+ const search2 = searchParamsCache2.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRolesWithCount({
+ ...search,
+ filters: validFilters,
+ }),
+
+
+ ])
+
+
+ const promises2 = Promise.all([
+ getUsersAll({
+ ...search2,
+ filters: validFilters,
+ }, "evcp"),
+ ])
+
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Role Management</h3>
+ <p className="text-sm text-muted-foreground">
+ 역할을 생성하고 역할에 유저를 할당할 수 있는 페이지입니다. 역할에 메뉴의 접근 권한 역시 할당할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <RolesTable promises={promises} promises2={promises2} />
+ </div>
+ </React.Suspense>
+
+ )
+}
diff --git a/app/[lng]/evcp/tag-numbering/page.tsx b/app/[lng]/evcp/tag-numbering/page.tsx
new file mode 100644
index 00000000..9d5b903a
--- /dev/null
+++ b/app/[lng]/evcp/tag-numbering/page.tsx
@@ -0,0 +1,74 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/tag-numbering/validation"
+import { getTagNumbering } from "@/lib/tag-numbering/service"
+import { TagNumberingTable } from "@/lib/tag-numbering/table/tagNumbering-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTagNumbering({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Tag Numbering from S-EDP
+ </h2>
+ <p className="text-muted-foreground">
+ 태그 넘버링을 위한 룰셋을 S-EDP로부터 가져오고 확인할 수 있습니다{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <TagNumberingTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/tasks/page.tsx b/app/[lng]/evcp/tasks/page.tsx
new file mode 100644
index 00000000..f14cc757
--- /dev/null
+++ b/app/[lng]/evcp/tasks/page.tsx
@@ -0,0 +1,63 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { DateRangePicker } from "@/components/date-range-picker"
+import { Shell } from "@/components/shell"
+
+import { FeatureFlagsProvider } from "@/lib/tasks/table/feature-flags-provider"
+import { TasksTable } from "@/lib/tasks/table/tasks-table"
+import {
+ getTaskPriorityCounts,
+ getTasks,
+ getTaskStatusCounts,
+} from "@/lib/tasks/service"
+import { searchParamsCache } from "@/lib/tasks/validations"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTasks({
+ ...search,
+ filters: validFilters,
+ }),
+ getTaskStatusCounts(),
+ getTaskPriorityCounts(),
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <TasksTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/vendors/[id]/info/items/page.tsx b/app/[lng]/evcp/vendors/[id]/info/items/page.tsx
new file mode 100644
index 00000000..e9ff17b4
--- /dev/null
+++ b/app/[lng]/evcp/vendors/[id]/info/items/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { getVendorItems } from "@/lib/vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsItemCache } from "@/lib/vendors/validations"
+import { VendorItemsTable } from "@/lib/vendors/items-table/item-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SettingsAccountPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsItemCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+
+
+ const promises = Promise.all([
+ getVendorItems({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Possible Items
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 딜리버리가 가능한 아이템 리스트를 확인할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <VendorItemsTable promises={promises} vendorId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/vendors/[id]/info/layout.tsx b/app/[lng]/evcp/vendors/[id]/info/layout.tsx
new file mode 100644
index 00000000..39e0bac0
--- /dev/null
+++ b/app/[lng]/evcp/vendors/[id]/info/layout.tsx
@@ -0,0 +1,79 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정
+import { Vendor } from "@/db/schema/vendors"
+
+export const metadata: Metadata = {
+ title: "Vendor Detail",
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string , id: string}
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 벤더 정보 조회
+ const vendor: Vendor | null = await findVendorById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "Contacts",
+ href: `/${lng}/evcp/vendors/${id}/info`,
+ },
+ {
+ title: "Items",
+ href: `/${lng}/evcp/vendors/${id}/info/items`,
+ },
+ {
+ title: "RFQ History",
+ href: `/${lng}/evcp/vendors/${id}/info/rfq-history`,
+ },
+ {
+ title: "Bidding History",
+ href: `/${lng}/evcp/vendors/${id}/info/bid-history`,
+ },
+ {
+ title: "Contract History",
+ href: `/${lng}/evcp/vendors/${id}/info/contract-history`,
+ },
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {vendor
+ ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보`
+ : "Loading Vendor..."}
+ </h2>
+ <p className="text-muted-foreground">벤더 관련 상세사항을 확인하세요.</p>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/vendors/[id]/info/page.tsx b/app/[lng]/evcp/vendors/[id]/info/page.tsx
new file mode 100644
index 00000000..6279e924
--- /dev/null
+++ b/app/[lng]/evcp/vendors/[id]/info/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { getVendorContacts } from "@/lib/vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsContactCache } from "@/lib/vendors/validations"
+import { VendorContactsTable } from "@/lib/vendors/contacts-table/contact-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SettingsAccountPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsContactCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+
+
+ const promises = Promise.all([
+ getVendorContacts({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Contacts
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 업무별 담당자 정보를 확인하세요.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <VendorContactsTable promises={promises} vendorId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/evcp/vendors/[id]/info/rfq-history/page.tsx
new file mode 100644
index 00000000..1d2f618c
--- /dev/null
+++ b/app/[lng]/evcp/vendors/[id]/info/rfq-history/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { getRfqHistory } from "@/lib/vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
+import { VendorRfqHistoryTable } from "@/lib/vendors/rfq-history-table/rfq-history-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqHistoryPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsRfqHistoryCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRfqHistory({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ RFQ History
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 벤더의 RFQ 참여 이력을 확인할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <VendorRfqHistoryTable promises={promises} />
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/vendors/page.tsx b/app/[lng]/evcp/vendors/page.tsx
new file mode 100644
index 00000000..e3cc7fdc
--- /dev/null
+++ b/app/[lng]/evcp/vendors/page.tsx
@@ -0,0 +1,78 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+
+import { searchParamsCache } from "@/lib/vendors/validations"
+import { getVendors, getVendorStatusCounts } from "@/lib/vendors/service"
+import { VendorsTable } from "@/lib/vendors/table/vendors-table"
+import { Ellipsis } from "lucide-react"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getVendors({
+ ...search,
+ filters: validFilters,
+ }),
+ getVendorStatusCounts(),
+ ])
+
+ return (
+ <Shell className="gap-2">
+
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Vendor Information
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더에 대한 요약 정보를 확인하고{" "}
+ <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. <br/>벤더의 상태에 따라 가입을 승인해주거나 PQ 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 벤더 코드를 따올 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <VendorsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}